本系列文章會在筆者的部落格繼續連載!Design System 101 感謝大家的閱讀!
Ripple Effect 是 Material Design 中的一個動畫效果,當使用者點擊 Button 時,會有一個水波紋的效果,讓使用者知道自己點擊的位置。

今天我們就要來實作 Ripple 組件!沒錯,又不是 Button 組件,因為工作到太晚了!
design-system > pnpm generate // name: ripple
design-system > cd packages/ripple
design-system/packages/ripple > pnpm i // 安裝相依套件
Ripple 的 API 設計相對簡單
| 屬性 | 描述 | 型別 | 預設值 | 
|---|---|---|---|
| color | Ripple 的顏色 | string | - | 
| target | Ripple 的附著範圍,Ripple 組件會在這個範圍內呈現動畫 | node | - | 
| className | Ripple Container 的額外樣式 | string | - | 
我們會透過 container 定義其動畫的範圍,並且由於 Ripple 只是屬於動畫呈現組件,可以用 aria-hidden 來隱藏 Ripple 的元素,這樣當 Screen Reader 讀取時,就不會讀到這個元素。
<-- container -->
<span aria-hidden="{true}">
  <-- animation effect -->
  <span />
</span>
在來透過 CSS 來實作 Ripple 的動畫效果,以及 Container 的範圍。
.tocino-Ripple__container {
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  height: 100%;
  width: 100%;
  overflow: hidden;
  pointer-events: none;
}
再來就是 Ripple 的動畫效果,這裡會先定義好,當 style 改變時,會透過 transition 來呈現動畫。
.tocino-Ripple {
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  transform: scale(0.0001, 0.0001);
  &.tocino--Ripple-animating {
    transform: none;
    transition: transform 0.15s linear, width 0.15s linear, height 0.15s linear, opacity 0.15s linear;
    will-change: transform, width, height, opacity;
  }
  &.tocino--Ripple-visible {
    opacity: 0.3;
  }
}
最後我們就需要監聽使用者點擊或是觸碰的事件,來觸發 Ripple 的動畫效果!
第一步驟將邏輯寫入 useRipple 的 hook 中:
狀態設計:
export const useRipple = ({ target, color }) => {
  const [rippleStyle, setRippleStyle] = useState({});
  const [rippleIsVisible, setRippleIsVisible] = useState(false);
  const rippleElRef = useRef(null);
  ...
}
事件邏輯:
接著透過 target 的傳入,我們可以使用 useEffect 來訂閱 tocuh 以及 mouse 事件。
useEffect(() => {
  target.current?.addEventListener('touchstart', showRipple, { passive: true });
  target.current?.addEventListener('mousedown', showRipple, { passive: true });
  target.current?.addEventListener('mouseup', hideRipple, { passive: true });
  target.current?.addEventListener('mouseleave', hideRipple, { passive: true });
  return () => {
    target.current?.removeEventListener('touchstart', showRipple);
    target.current?.removeEventListener('mousedown', showRipple);
    target.current?.removeEventListener('mouseup', hideRipple);
    target.current?.removeEventListener('mouseleave', hideRipple);
  };
}, []);
當使者點擊 Button 時,會觸發 showRipple 事件,並且透過 rippleElRef 來取得 ripple 的元素,並且計算出 ripple 的位置。
const showRipple = useCallback(
  (evt) => {
    const buttonEl = target.current;
    const offset = domUtils.offset(buttonEl);
    const clickEvent = evt.type === 'touchstart' && evt.touches ? evt.touches[0] : evt;
    const radius = Math.sqrt(offset.width * offset.width + offset.height * offset.height);
    const diameterPx = radius * 2 + 'px';
    setRippleStyle({
      top: Math.round(clickEvent.pageY - offset.top - radius) + 'px',
      left: Math.round(clickEvent.pageX - offset.left - radius) + 'px',
      width: diameterPx,
      height: diameterPx,
      backgroundColor: color,
    });
    setRippleIsVisible(true);
  },
  [rippleElRef, color],
);
最後在事件結束後,觸發 hideRipple 事件,讓 ripple 消失。
const hideRipple = useCallback(() => {
  setRippleIsVisible(false);
}, []);
這樣就完成 Ripple 組件了! 接下來在 Button 組件則是這樣使用
<button ref={btnRef}>
   <span>{children}</span>
   <Ripple target={btnRef} color="rgba(0, 0, 0, 0.1)" />
</button>
就可以看到一開始 gif 所呈現的效果了!所有的程式碼可以參考這裡!